對於 Lisp ,我有個看法繼承自 Clojure 社群:
開發應用軟體,要儘量少用 Lisp Macro 。
如果是 Common Lisp 社群的話,很可能覺得這種看法簡直是胡說八道, 因為在 Common Lisp 社群普遍認為:「Macro 是 Lisp 的精髓所在,它們不只是語法糖,而是讓工程師能夠為特定問題定義新語言,最後讓程式碼成為更能表達其意圖的絕佳工具。」
此外,讀者可能還讀過了《黑客與畫家》一書,也讀過了 Paul Graham 大力誦揚 Lisp 的一篇文章:Beating The Averages。確實,那一篇文章就是講 Lisp 因為有 Lisp Macro ,所以可以讓軟體工程師的生產力超越平庸啊。而且,Paul Graham 還舉証:「在 Viaweb 的程式碼庫中,有高達 20-25% 的程式碼是 Macro。」
這部分的爭辯,不妨先擱置,等學會了 Macro 再來研究要不要積極使用。
談論 Macro 之前,先舉兩類比較有代表性的語法:
{:key value}
和 [:a :b :c]
:這兩種宣告 Table 的語法稱之為 Literal Syntax。它們不是 S 表達式。這類型的語法,會由 Lisp 編譯器在 read 階段處理。如果某個 Lisp 編譯器有提供 Reader Macro 的話,Reader Macro 就可以做出類似的 Literal Syntax。->
:這是 Thread first 的語法。這種語法是 S 表達式的形式,所以可以透過 Lisp Macro 來實現。下圖是一個從源碼到可運作程式的流程圖,它可以拆分成兩個階段的工作:
多數的時候,如果我們沒有特別註明 Reader Macro 時,我們所談論的 Macro 都是指運作在階段二的 Lisp Macro 。
在 Lua 宣告 Table 的語法是:
local fruits = {"apple", "banana", "orange"}
local person = {
name = "Alice",
age = 30,
city = "Taipei"
}
對應的 Fennel 語法是:
(local fruits [:apple :banana :orange])
(local person {:age 30 :city :Taipei :name :Alice})
而 Reader Macro 運作原理如下:
當讀取器 (Reader) 在讀取 (read) 源碼時,當它讀到了 […]
字元時,它就會觸發 [...]
對應的 Reader Macro ,於是將其解析成 (sequential-table ...)
;同樣的道理,當它讀到了 {…}
字元時,它就會觸發 {...}
對應的 Reader Macro ,於是將其解析成 (general-table ...)
。(此處是假設上述的 Fennel 語法是透過 Reader Macro 來實作,但是實質上 Fennel 是直接實作在 Reader 之內)
觀察上頭的例子,可以發現,Reader Macro 只改變了程式碼的呈現形式,換言之,它只提供了新語法。
相對於 Reader Macro ,Lisp Macro 的核心能力在於定義新的語意,也就是『改變程式碼的行為,而不只是改變呈現形式。』讓我們以 Fennel 的 Thread First Macro ->
為例,解釋這個概念。
在 Fennel 裡,如果你想對一個資料進行一系列操作,你會將函數呼叫層層嵌套,從內往外寫。例如,你想先將一個數字加一,再乘以十,最後取絕對值:
(math.abs (* 10 (+ 1 5)))
這段程式碼的閱讀順序是從內層開始往外讀的:(+ 1 5)
→ (* 10 ...)
→ (math.abs ...)
。這種從內到外的寫法,對於複雜的連續操作來說,會讓程式碼變得難以閱讀和理解。
Fennel 的 ->
Macro 解決了這個問題,它讓你能用一種更自然、更線性的方式來表達相同的邏輯:
(-> 5
(+ 1)
(* 10)
math.abs)
這就是 Macro 提供新語意的絕佳例子。標準的 Lisp 並沒有內建「將前一個運算的結果作為參數,傳給下一個運算」的語法。->
是一個 Macro,它的作用是在編譯階段,將你寫的線性程式碼:
(-> 5 (+ 1) (* 10) math.abs)
重寫成嵌套的 S 表達式:
(math.abs (* 10 (+ 1 5)))
這個重寫的過程,就是 Macro 賦予程式碼新行為的體現。它不僅僅是替換掉一些字元,而是創造出一個全新的運算流程和邏輯。
在 Fennel 的官網也有隱含地提到 Reader Macro ,不過是用極簡主義的方式提。
The parse-error and assert-compile hooks can be used to override how fennel behaves down to the parser and compiler levels. Possible use-cases include building atop fennel.view to serialize data with EDN-style tagging, or manipulating external s-expression-based syntax, such as tree-sitter queries.
我來翻譯一下:parse-error
和 assert-compile
這兩個 hooks 可以在 Fennel 的 parser 和 compiler 階段加入新的行為。
其實很多的程式語言,都有 meta programming 這種進階議題。換言之,這些可以做 meta programming 的程式語言,也提供了等價於 Lisp Macro 的功能:也就是用程式來寫程式。
然而,可以做到等價的事,不表代等價的輕鬆。Lisp Macro 絕對是表達能力最強的、寫起來最輕鬆的。
Lisp Macro 最大的特色就是:你所做的事情就是定義一個函數,將一組語法樹轉換成另一組語法樹。
比方說前述的 Thread Macro ->
,它的引數是:
5 (+ 1) (* 10) math.abs
引數是一組語法樹。
而它的輸出是:
(math.abs (* 10 (+ 1 5)))
輸出又是另一組語法樹。
由於在真實的軟體開發應用情境裡,真的需要寫 Lisp Macro 的機率並不高。如何撰寫的部分,就留給讀者自行研究。
另一方面,有一類特殊的 Lisp Macro 用法,卻是 Clojure 社群也鼓勵多多使用的,它們可稱之為 With-Macro 。
比方說,Fennel 就有一個 with-open
語法,這就是一種 With-Macro 。
用法如下:
;; Basic usage
(with-open [fout (io.open :output.txt :w) fin (io.open :input.txt)]
(fout:write "Here is some text!\n")
((fin:lines))) ; => first line of input.txt
它的功能是會在結束檔案讀取時,幫忙處理關閉檔案、釋放資源的動作。換言之,它雖然也做『行為』的改變,但是,它所做的行為改變,通常只有在前後做一些上下文的管理。
這類型的 With-Macro 在處理和副作用相關的函數呼叫時,特別地有用。
在 Python 語言,並沒有 Lisp Macro ,但是卻有相當於 With-Macro 的設計,它叫做 Context Manager。
# 沒有使用 with 的寫法
f = open('test.txt', 'r')
try:
content = f.read()
# 即使這裡發生錯誤,finally 區塊的 f.close() 依然會被執行。
# 這種寫法比較繁瑣,需要手動處理資源關閉。
print(content)
finally:
f.close()
---
# 使用 with 的寫法
with open('test.txt', 'r') as f:
content = f.read()
# 這裡的程式碼如果發生錯誤,`with` 會確保 `f` 物件的
# `__exit__` 方法被自動呼叫,從而正確關閉檔案。
# 這種寫法更簡潔、安全。
print(content)
本篇解釋了幾個重要的概念:Reader Macro、Lisp Maco、程式碼的呈現形式、程式碼的行為、With-Macro 。以及,程式語言的讀取和編譯到底是怎樣的過程。
當你下回聽到別人想要設計或是開發新的程式語言或是 DSL 時,不妨問問他:「你有學過 Lisp 嗎?」
如果答案是 Yes,那這個人是有先做過功課的。